In dieser zweiteiligen Artikelserie zeige ich Ihnen, wie Sie beide Anforderungen unter einen Hut bekommen. Der erste Teil beleuchtet die Umsetzung Ihres strategischen Designs auf der Basis von Standalone Components und Standalone APIs. Die vorgegebene Architektur wird mit dem Open-Source-Projekt Sheriff umgesetzt. Die verwendeten Beispiele finden sich in meinem GitHub-Konto unter [1].
Die Leittheorie: Strategic Design aus DDD
Als Leittheorie für die Strukturierung moderner Frontends hat sich Strategic Design, eine der beiden ursprünglichen Disziplinen von Domain-Driven Design (DDD), bewährt. Im Kern geht es dabei darum, ein Softwaresystem in verschiedene fachliche Teilbereiche (Subdomänen) zu zerlegen. Bei einer Fluggesellschaft könnte man zum Beispiel die in Abbildung 1 dargestellten Subdomänen finden.
Die Identifizierung der einzelnen Domänen erfordert einen Blick auf die zu unterstützenden Geschäftsprozesse. Das Zusammenspiel zwischen Entwickler:innen sowie Architekt:innen auf der einen und Domänenexpert:innen auf der anderen Seite ist dabei essenziell. Workshopformate wie Event Storming [2], die DDD mit Ideen der agilen Softwareentwicklung verbinden, bieten sich hierfür an.
Die Beziehungen und Abhängigkeiten zwischen einzelnen Domänen werden in einer Context Map dargestellt (Abb. 2). Ziel ist es, die einzelnen Domänen voneinander zu entkoppeln. Je weniger sie voneinander wissen, desto besser. Das verhindert, dass sich Änderungen an einem Anwendungsteil auf andere Teile auswirken, und verbessert so die Wartbarkeit. Bei größeren Projekten ist es üblich, jedem Teilteam eine oder mehrere Domänen zuzuweisen (Kasten: „Domäne vs. Bounded Context“).
Um die gewünschte Entkopplung zu erreichen, könnte Booking im gezeigten Beispiel nur einige ausgewählte Services veröffentlichen. Alternativ dazu könnten Informationen über gebuchte Flüge per Messaging im Backend verteilt werden. Darüber hinaus bietet Strategic Design zahlreiche weitere Muster und Überlegungen, die bei der Umsetzung einer losen Kopplung helfen.
BRINGEN SIE LICHT INS ANGULAR-DUNKEL
Die ersten Schritte in Angular geht man am besten im Basic Camp.
→ Nächster Termin: 9. - 11. Dezember, online
Domäne vs. Bounded Context
Streng genommen wird eine Domäne im Rahmen der Implementierung auf einen oder mehrere Bounded Contexts abgebildet, und jeder Bounded Context kann wiederum eine oder mehrere Domänen enthalten. Somit spiegelt der Bounded Context die Lösungssicht wider, während die Domäne einen Teil der Problemsicht darstellt.
Jeder Bounded Context hat ein Domänenmodell, das die jeweilige Fachlichkeit widerspiegelt, z. B. die Struktur und Handhabung von Flügen und Tickets. Dieses Domänenmodell ist nur innerhalb des Bounded Context sinnvoll. Auch wenn die gleichen Begriffe in anderen Kontexten verwendet werden, ist es sehr wahrscheinlich, dass diese Kontexte eine andere Sichtweise darauf haben. Ein Flug sieht aus Booking-Sicht anders aus als aus Sicht des Boarding. Diese beiden Sichten werden bewusst getrennt dargestellt. Damit vermeidet man sowohl eine Vermischung der Kontexte als auch ein unübersichtliches Modell, das versucht, zu viel auf einmal zu beschreiben.
Der Einfachheit halber gehen die Ausführungen in diesem Artikel davon aus, dass pro Domäne ein Bounded Context vorherrscht.
Übergang zum Quellcode: die Architekturmatrix
Für die Umsetzung im Quellcode bietet es sich an, die einzelnen Domänen weiter in verschiedene Module zu untergliedern (Abb. 3).
Eine Kategorisierung dieser Module erhöht die Übersichtlichkeit. In [3] werden unter anderem folgende Kategorien vorgeschlagen, die sich in unserer täglichen Arbeit bewährt haben:
-
Feature: Ein Feature-Modul implementiert einen Anwendungsfall (oder auch ein technisches Feature) mit sogenannten Smart Components. Durch die Fokussierung auf ein Feature sind solche Komponenten wenig wiederverwendbar. Ein weiteres Merkmal ist, dass Smart Components mit dem Backend kommunizieren. Typischerweise erfolgt diese Kommunikation in Angular über einen Store oder Services.
-
UI: UI-Module enthalten sogenannte Dumb oder Presentational Components. Dabei handelt es sich um wiederverwendbare Komponenten, die die Implementierung einzelner Features zwar unterstützen, sie aber nicht direkt kennen. Die Umsetzung eines Designsystems besteht aus solchen Komponenten. UI-Module können aber auch allgemeine fachliche Komponenten enthalten, die anwendungsfallübergreifend zum Einsatz kommen. Ein Beispiel hierfür wäre eine Ticketkomponente, die sicherstellt, dass Tickets in verschiedenen Features einheitlich dargestellt werden. Häufig kommunizieren solche Komponenten nur über Eigenschaften und Events mit ihrer Umgebung. Sie haben keinen Zugriff auf das Backend oder einen Store.
-
Data: Data-Module enthalten das jeweilige Domänenmodell (eigentlich die clientseitige Sicht darauf) sowie Services, die darauf operieren. Solche Services validieren z. B. Entitäten und kommunizieren mit dem Backend. Auch das State Management inkl. der Bereitstellung von ViewModels kann in Data-Modulen untergebracht werden. Das ist vor allem dann sinnvoll, wenn mehrere Features derselben Domäne auf denselben Daten basieren.
-
Util: Allgemeine Hilfsfunktionen werden in Utility-Modulen untergebracht. Beispiele dafür sind Logging, Authentifizierung oder das Arbeiten mit Datumswerten.
Eine weitere Besonderheit bei der Umsetzung im Code ist der shared-Bereich, der Code für alle Domänen bereitstellt. Er sollte in erster Linie technischen Code enthalten – fachlicher Code ist in der Regel in den einzelnen Domänen zu finden.
Die hier dargestellte Struktur bringt Ordnung in das System: Es gibt weniger Diskussionen darüber, wo bestimmte Codeabschnitte zu finden bzw. zu platzieren sind. Außerdem lassen sich auf Basis dieser Matrix zwei einfache, aber wirkungsvolle Regeln einführen:
-
Jede Domäne darf im Sinne des Strategic Design nur mit ihren eigenen Modulen kommunizieren. Eine Ausnahme bildet der shared-Bereich, auf den jede Domäne Zugriff hat.
-
Jedes Modul darf nur auf Module in tieferen Schichten der Matrix zugreifen. Dabei geht aus jeder Modulkategorie eine eigene Schicht hervor.
Beide Regeln unterstützen die Entkopplung der einzelnen Module bzw. Domänen und helfen, Zyklen zu vermeiden.
Projektstruktur für die Architekturmatrix
Die Architekturmatrix kann im Quellcode in Form von Ordnern abgebildet werden: Jede Domäne bekommt einen eigenen Ordner, der wiederum für jedes seiner Module einen Unterordner erhält (Abb. 4).
Die Modulnamen haben den Namen der jeweiligen Modulkategorie als Präfix. So ist auf den ersten Blick ersichtlich, wo in der Architekturmatrix sich das jeweilige Modul befindet. Innerhalb der Module gibt es typische Angular Building Blocks wie Komponenten, Direktiven, Pipes oder Services.
Der Einsatz von Angular-Modulen ist seit der Einführung von Standalone Components (Dirketiven und Pipes) nicht mehr notwendig. Stattdessen wird die Flag standalone auf true gesetzt (Listing 1). Bei Komponenten ist außerdem der sogenannte Compilation Context zu importieren. Dabei handelt es sich um alle weiteren Standalone Components, Direktiven und Pipes, die im Template zum Einsatz kommen.
Listing 1
@Component({
selector: 'app-flight-booking',
standalone: true,
imports: [CommonModule, RouterLink, RouterOutlet],
templateUrl: './flight-booking.component.html',
styleUrls: ['./flight-booking.component.css'],
})
export class FlightBookingComponent {
}
export * from './flight-booking.routes';
Bei der Wartung der veröffentlichten Konstrukte ist Vorsicht geboten, da sich Breaking Changes tendenziell auch auf andere Module auswirken. Alles jedoch, was hier nicht veröffentlicht wird, ist ein Implementierungsdetail des Moduls. Änderungen an diesen Teilen sind daher weniger kritisch.
Domänenschnitt mit Sheriff erzwingen
Die bisher diskutierte Architektur basiert auf mehreren Konventionen:
-
Module dürfen nur mit Modulen derselben Domäne sowie shared kommunizieren.
-
Module dürfen nur mit Modulen tieferliegender Schichten kommunizieren.
-
Module dürfen nur auf die öffentliche Schnittstelle anderer Module zugreifen.
Mit dem Open-Source-Projekt Sheriff [4] können diese Konventionen via Linting erzwungen werden. Bei Nichteinhaltung wird eine Fehlermeldung in der IDE (Abb. 5) oder auf der Konsole (Abb. 6) ausgegeben.
Ersteres liefert unmittelbares Feedback während der Entwicklung, wohingegen Letzteres im Build-Prozess automatisiert werden kann. Dadurch lässt sich zum Beispiel verhindern, dass Quellcode, der gegen die definierte Architektur verstößt, im main– bzw. dev-Branch des Quellcode-Repos landet.
Um Sheriff einzurichten, müssen die folgenden beiden Pakete über npm bezogen werden:
npm i @softarc/sheriff-core @softarc/eslint-plugin-sheriff -D
{
[...],
"overrides": [
[...]
{
"files": ["*.ts"],
"extends": ["plugin:@softarc/sheriff/default"]
}
]
}
Listing 3
import { noDependencies, sameTag, SheriffConfig } from '@softarc/sheriff-core';
export const sheriffConfig: SheriffConfig = {
version: 1,
tagging: {
'src/app': {
'domains/<domain>': {
'feature-<feature>': ['domain:<domain>', 'type:feature'],
'ui-<ui>': ['domain:<domain>', 'type:ui'],
'data': ['domain:<domain>', 'type:data'],
'util-<ui>': ['domain:<domain>', 'type:util'],
},
},
},
depRules: {
root: ['*'],
'domain:*': [sameTag, 'domain:shared'],
'type:feature': ['type:ui', 'type:data', 'type:util'],
'type:ui': ['type:data', 'type:util'],
'type:data': ['type:util'],
'type:util': noDependencies,
},
};
Die Abhängigkeitsregeln unter depRules greifen die einzelnen Kategorien auf und legen zum Beispiel fest, dass ein Modul nur auf Module derselben Domäne und auf domain:shared zugreifen darf. Weitere Regeln definieren, dass jede Schicht nur auf darunterliegende Schichten zugreifen darf. Dank der Regel root: [‚*‘] dürfen alle nicht explizit kategorisierten Ordner im Root-Ordner und darunter auf sämtliche Module zugreifen. Das betrifft vor allem die Shell der Anwendung.
ABTAUCHEN IM DEEP DIVE
Im Fortgeschrittenen Camp tauchen Sie ab unter die Oberfläche einer modernen Angular-Anwendung.
→ Nächster Termin: 2. - 4. Dezember, Berlin
Leichtgewichtige Path-Mappings
Um unleserliche relative Pfade innerhalb der Imports zu vermeiden, bieten sich Path Mappings an. Diese erlauben es zum Beispiel, statt
import { FlightBookingFacade } from '../../data';
folgende Formulierung zu verwenden:
import { FlightBookingFacade } from '@demo/ticketing/data';
{
"compileOnSave": false,
"compilerOptions": {
"baseUrl": "./",
[...]
"paths": {
"@demo/*": ["src/app/domains/*"],
}
},
[...]
}
Standalone APIs
Da Standalone Components die umstrittenen Angular-Module optional machen, stellt das Angular-Team nun sogenannte Standalone APIs zur Registrierung von Bibliotheken zur Verfügung. Bekannte Beispiele sind provideHttpClient und provideRouter (Listing 5).
Listing 5
bootstrapApplication(AppComponent, {
providers: [
provideHttpClient(),
provideRouter(APP_ROUTES, withPreloading(PreloadAllModules)),
importProvidersFrom(NextFlightsModule),
importProvidersFrom(MatDialogModule),
provideLogger({
level: LogLevel.DEBUG,
}),
],
});
Aus Architektursicht erfüllen Standalone-APIs noch einen weiteren Zweck: Sie erlauben es, einen Systembestandteil als Blackbox zu betrachten, die unabhängig weiterentwickelt werden kann. Aus der Blackbox kann durch die Übergabe eines Konfigurationsobjekts auch eine Graybox werden. In diesem Fall kann das Verhalten des genutzten Systembestandteils über wohldefinierte Einstellungen angepasst werden, ohne die lose Kopplung aufgeben zu müssen. Man spricht hier auch vom Open/Closed-Prinzip: offen für Erweiterungen (durch Konfiguration bzw. Polymorphismus), geschlossen für Modifikationen durch User.
Als Beispiel für ein eigenes Standalone API, das einen Logger einrichtet, wird in Listing 5 die Funktion provideLogger aufgerufen. Ihre Implementierung ist in Listing 6 zu sehen. Die Funktion provideLogger nimmt ein partielles LoggerConfig-Objekt entgegen. Der Aufrufer muss sich also nur um die Parameter kümmern, die für den aktuellen Fall relevant sind. Um eine vollständige LoggerConfig zu erhalten, führt provideLogger die übergebene Konfiguration mit einer Standardkonfiguration zusammen. Darauf aufbauend werden verschiedene Provider zurückgegeben. Die Funktion makeEnvironmentProviders aus @angular/core umhüllt das erzeugte Providerarray mit einem Objekt vom Typ EnvironmentProviders. Dieser Typ lässt sich beim Bootstrappen der Anwendung sowie innerhalb von Routingkonfigurationen nutzen. Er erlaubt somit die Bereitstellung von Providern für die gesamte Anwendung oder einzelne Teile.
Listing 6
export function provideLogger(
config: Partial<LoggerConfig>
): EnvironmentProviders {
const merged = { ...defaultConfig, ...config };
return makeEnvironmentProviders([
LoggerService,
{
provide: LoggerConfig,
useValue: merged,
},
{
provide: LOG_FORMATTER,
useValue: merged.formatter,
},
merged.appenders.map((a) => ({
provide: LOG_APPENDERS,
useClass: a,
multi: true,
})),
]);
}
Zusammenfassung
Strategic Design unterteilt ein System in verschiedene Teile, die möglichst unabhängig voneinander implementiert werden. Diese Entkopplung verhindert, dass sich Änderungen in einem Anwendungsbereich auf andere auswirken. Der vorgestellte Architekturansatz unterteilt die einzelnen Domänen in verschiedene Module, wobei das quelloffene Projekt Sheriff sicherstellt, dass die einzelnen Module nur nach den aufgestellten Regeln miteinander kommunizieren.
Dieser Ansatz ermöglicht die Umsetzung großer und langfristig wartbarer Frontend-Monolithen. Wegen ihres modularen Aufbaus spricht man auch von Modulithen. Ein Nachteil solcher Architekturen sind erhöhte Build- und Testzeiten. Dieses Problem kann durch inkrementelle Builds und Tests gelöst werden. Der zweite Teil dieser Artikelserie beschäftigt sich mit diesem Thema.
Links & Literatur
[1] https://github.com/manfredsteyer/modern-arc.git
[2] https://www.eventstorming.com
[3] https://go.nrwl.io/angular-enterprise-monorepo-patterns-new-book
Newsletter
Jetzt anmelden & regelmäßig wertvolle Insights in Angular sowie aktuelle Weiterbildungen erhalten!